Cap resident MCP session runtimes with LRU eviction - #1842
Merged
Conversation
Isolates were letting resident session runtimes pile up unbounded past the point idle disposal alone could keep up, so memory pressure kept climbing until the platform reset the whole isolate. init now evicts the least-recently-active evictable session once the isolate hits a soft cap, reusing the same disposal path and eligibility signals the idle alarm already used. A cap that finds nothing evictable never blocks or fails init; it only marks the init span.
Contributor
Cloudflare previewTorn down — the PR is closed. |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | aa2dc3b | Commit Preview URL Branch Preview URL |
Aug 29 2026, 09:05 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | aa2dc3b | Aug 29 2026, 09:05 AM |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
…ontext Cap eviction now asks the candidate's own Durable Object stub to tear itself down instead of running its teardown inside the evicting session's request context, where its I/O objects aren't valid. Duplicate or failed requests are safe no-ops; a recently-requested candidate is skipped by the next pick.
Reserve an in-flight cold-build slot at admission so concurrent cold inits at the cap evict instead of all passing the check before any of their builds finish incrementing residency. Track in-progress runtime disposal and flip initialized before the first async close, so a request landing mid-teardown awaits the same disposal and rebuilds instead of running against a half-closed runtime; a second concurrent disposal trigger waits on the same teardown instead of running it twice. Close every MCP session opened by the cap-eviction e2e scenario via Effect.ensuring.
… cleanup - release the cold-build reservation with one Effect.ensuring around all of init, so an interrupt between cap admission and the build can't leak it - make closeRuntime's teardown uninterruptible so disposingRuntime never resolves against half-released resources - record e2e session ids for cleanup as soon as they're minted, not after the handshake completes, so a failed notification can't orphan a session
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Isolates were letting resident MCP session runtimes accumulate unbounded.
Idle disposal exists, but it is alarm-driven per session, so a busy isolate
with many quiet-but-connected sessions could still pile up far past what the
isolate's memory can hold, until the platform reset every session on it.
This turns the existing residency gauge into an enforcing soft cap:
session-runtime-residency.tsgets a small dependency-free registry(
registerResidentSession,touchResidentSession,releaseResidentSession,pickEvictionCandidate) plusRESIDENT_RUNTIME_SOFT_CAP(32).initrequests at most one eviction of theleast-recently-active evictable session if the isolate is already at the
cap. Eligibility mirrors the same signals the idle-alarm path already uses
(no active stream, no paused/running executions), split into a cheap sync
prefilter used for LRU selection and an authoritative async re-check right
before the actual dispose, so a session that started work in between is
left alone.
(
disposeIdleRuntime), now parameterized with areason: "idle" | "cap"attribute on the existing
mcp.session.idle_runtime_disposespan (spanname kept for dashboard continuity).
mechanism must never itself be the reason a session fails to start — and
mcp.isolate.cap_overflow: trueis recorded on the init span instead.Eviction runs in the candidate's own context, not the evictor's
A session's postgres.js socket, storage handle, and span exporter are bound
to the Durable Object request/IoContext that created them. The evicting
session's
initcannot tear another session's runtime down directly — thatwould run I/O created under one request's context from inside a different
one, which workerd rejects (or silently no-ops) in production, even though
nothing catches it in an in-process unit test where both sessions are just
plain JS objects in the same isolate.
So
disposeon a registry entry no longer runs the candidate's teardown —it sends the candidate a REQUEST that the candidate's own runtime executes in
its own context:
requestSelfEviction()hook and asupportsCapEviction()gate. A host that can route a self-addressedrequest implements both; a host that can't simply never registers a
dispose, so its sessions are never eviction candidates — degrading to
observational rather than breaking.
apps/cloudandapps/host-cloudflareimplementrequestSelfEvictionbycalling their own Durable Object stub (
mcpSessionStub(...).requestCapEviction()),the same pattern
forwardModelResumeToOwneralready uses to route a resumeacross sessions.
requestCapEvictionis a new public RPC method on the DO that runsevictResidentRuntimeForCap()— the same authoritative re-check-then-disposelogic as before, just invoked from the candidate's own request instead of
the evictor's.
ctx.waitUntilfrominitand moves on;initnever waits on another session's teardown tocomplete its own request.
evictionRequestedAtthe moment therequest is sent, and
pickEvictionCandidateskips any entry with a requeststill inside a short grace window — so a candidate whose request is slow or
stuck can't be re-targeted by every subsequent
initbefore it resolves.Duplicate and failed requests are both safe:
inits can pick the same candidate before eitherrequest lands. The candidate's own handler re-checks liveness every time,
and its teardown is idempotent, so the second request either finds the
runtime already gone (no-op) or finds it busy again and leaves it alone.
entry is left in place — only the candidate's own successful teardown
removes it — and the failure is logged. The evictor's own
initis neverblocked or failed by another session's eviction outcome.
Closing two admission/teardown races
Two further races surfaced on review:
isolate's resident count against the cap, but residency only counts a
session once its (async) build finishes. Several overlapping cold inits
admitted at the same moment each read the count as still under the cap and
none of them evicted anything, so residency could overshoot the cap by
however many builds landed together — the exact burst the cap exists to
bound.
session-runtime-residency.tsnow also tracks an in-flightcold-build count, reserved the moment an init is admitted and released once
that build finishes or fails; the cap check is now
resident count + in-flight count >= cap, so a second concurrent admissionsees the first one's reservation and evicts instead of also passing the
check for free.
closeRuntime(used by both idle disposal and cap eviction) leftinitializedtrueacross its async closes (server.close(),dbHandle.end()), so a request arriving in that window tookinit'searly-return path and ran against a server/engine that were already gone or
partway through closing.
closeRuntimenow flipsinitializedtofalsebefore its first async close and tracks the in-progress disposal;
initawaits any in-progress disposal before deciding whether to rebuild, and a
second disposal trigger landing while one is already running (e.g. the idle
alarm and a cap eviction request together) waits on the same disposal
instead of tearing the runtime down twice.
Also, the cap-eviction e2e scenario now closes every MCP session it opens via
an
Effect.ensuringfinalizer, instead of leaving ~34 real sessions open forthe rest of the e2e run.
Closing three interruption-path edges
The two races above were closed for the ordinary failure paths, but an
interrupt landing at the same seams could still slip through:
reservation was taken by
evictForCapIfNeeded, but only released by anEffect.ensuringscoped to the build block that starts right after thatadmission — leaving a gap between the two. An interrupt landing in exactly
that gap leaked the reservation permanently, since nothing ever released
it. The release is now a single
Effect.ensuringaround init's entireprogram, so there is no window between acquiring the reservation and being
covered by its release.
closeRuntime'sEffect.ensuringresolvesdisposingRuntimeunconditionally, and a waiting
inittreats that resolution as "theresources are actually released." An interrupt landing mid-teardown (e.g.
while still inside the gated
server.close()) used to let thatensuringfire anyway, so a waiting
initcould rebuild against a still-open serveror un-cleared engine. The teardown body is now
Effect.uninterruptible,so an interrupt request has to wait for it to actually finish first.
session id was only pushed into the cleanup list after the full
initialize+notifications/initializedhandshake returned. A failednotification, or an interrupt landing between the two requests, orphaned a
real session with no cleanup entry. The id is now recorded the moment it's
known — right after
initializeresponds — not after the handshakecompletes.
Verification
Unit tests (
agent-session-durable-object.test.ts): 38/38 passing, includingnew cases for concurrent cold-build admission, mid-disposal request handling,
an init interrupted between cap admission and build completing (reservation
still released, not leaked), and a disposal interrupted mid-teardown (a
waiting
initstays blocked until the now-uninterruptible teardown actuallyfinishes, instead of rebuilding early). Typecheck, lint, and format all
clean.
e2e:
cloud/mcp-session-idle-runtime-disposal.test.ts— 1/1 passing, confirmingthe shared disposal path is unaffected.
cloud/mcp-session-cap-eviction.test.ts— 1/1 passing: a genuinely real,workerd-backed eviction test against a lowered
MCP_RESIDENT_RUNTIME_SOFT_CAP(seee2e/setup/resident-runtime-cap.ts),asserting a
mcp.session.idle_runtime_disposespan withdispose_reason: "cap"was exported for one of the opened sessions, thatthe evicted session still serves its next call correctly after restoring,
and cleaning up every session it opened — including any left partially
established.